JavaScript 核心 - 你其實已經用過閉包(closure)


Posted by ai86109 on 2020-09-29

前言

如果你還不清楚 EC, VO 等名詞以及其運作,建議先看:JavaScript 核心 - 來講講提升(hoisting),不然可能會看不懂這篇所要講的東西。

話不多說,直接先看個程式碼

function complex(num) {
  console.log('calculate')
  return num * num * num
}

function cache(func) {
  var ans = {}
  return function(num) {
    if(ans[num]) {
      return ans[num]
    }
    ans[num] = func(num)
    return ans[num]
  }
}

const cachedComplex = cache(complex)
console.log(cachedComplex(20))
console.log(cachedComplex(20))
console.log(cachedComplex(20))

我們先來看一下變數 cachedComplex

因為他會 call cache 這個 function,而 cache 最後會 return 裡面一個 function,所以我們可以看成

const cachedComplex = return function(num) {
  if(ans[num]) {
    return ans[num]
  }
  ans[num] = func(num)
  return ans[num]
}

所以 call cachedComplex 這個 function,並帶入參數 20,這個參數就會是 num

const cachedComplex = cache(complex) 除了 call cache 這個 function 外,也帶入參數 complex(也就是最上面這個 function complex)

所以 function cache(func) 的 func 就是 function complex

OK,搞懂所有變數之後,我們就來實際跑一遍

-

第一次 console.log call cachedComplex 帶參數 20 給 num

call cache 這個參數並帶 complex 這個 function 給 func

進入 cache 裡,此時 ans 是一個空物件

進到匿名函式中,因為 ans[num] 是 false,所以 if 不成立,因此繼續往下執行

ans[num] = func(num),帶入參數後 ans[20] = complex(20)

進入 complex 後,先印出 calculate

接著回傳 8000

ans[20] = 8000

所以 ans = { 20: 8000 }

並且將 ans[20] 的值回傳

剛剛我們說到 const cachedComplex = cache(complex) 可以看成

const cachedComplex = return function(num) {
  if(ans[num]) {
    return ans[num]
  }
  ans[num] = func(num)
  return ans[num]
}

所以 const cachedComplex = ans[20],會印出剛剛回傳的 8000

第二次 console.log 一樣帶參數 20,中間過程一樣

進到匿名函式中,因為 ans[num] 剛剛已經有值了,所以是 true

if(ans[num]) {
  return ans[num]
}

回傳 ans[20],最後印出 8000

*注意這邊是直接回傳,並沒有經過 function complex,所以不會回傳 calculate!

最後全部跑完會印出

calculate
8000
8000
8000

而以上這種在 function 裡 return function,又可以把值記住就是閉包的基本應用。


運作的規則

在講閉包前你該知道的事

根據 ECMAScript 的規範,每一個 EC 都有 scope chain,當你進入一個新的 function EC 時,scope chain 就會被建立。

Scope chain 裡包含 AO(Activation Object),還有 [[Scope]] 這個東西(只有 function 的 EC 才有)

scope chain: [AO, [[Scope]] ]

AO 是什麼?

其實 AO 跟我們之前說的 VO 很像

現階段就把他看成,在 global EC 裡的是 VO,在其他 function 裡的就是 AO

那什麼是 [[Scope]]

當我們宣告 function 時,就會產生一個隱藏屬性

function名字.[[Scope]] = 這一層的 scope chain

這邊先知道這樣就好,直接看範例走一遍就知道了!

var a = 1

function test() {
  var b = 2
  function inner(){
    var c = 3
    console.log(b)
    console.log(a)
  }
  inner()
}

test()

-
第一步先初始化

剛說到因為 global 不是 function 所以會建立 VO

然後進入一個新的 EC,scope chain 就會被建立

因為 global 有 function,所以也會建立一個隱藏的屬性 [[Scope]]

globalEC: {
  VO: {
    a: undefined,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
初始化完成,開始逐行執行賦值等

最後也執行到 test(),進入 testEC 的初始化

因為是 function 所以建立 AO

function test 內有 function,所以也建立 [[Scope]]

testEC: {
  AO:{
    b: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]]
  // 其實[testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.scopeChain] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain
// 這邊也可以看成 = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
接著我們一樣繼續逐行執行,然後 call inner

innerEC: {
  AO:{
    c: undefined
  },
  scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]
}

testEC: {
  AO:{
    b: 2,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
逐行執行(因為這邊只有要幫 c 賦值,所以就不寫了)

接著 console.log(b),我們透過 scope chain 來找

scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]

在 innerEC 的 AO 沒找到,再去 testEC.AO 找

找到了 b,所以印出 2

再來 console.log(a),一樣用 scope chain 來找

在 innerEC 的 AO 沒找到,再去 testEC.AO 找也沒找到,再去 globalEC.VO 找

找到了 a,所以印出 1


所以我說那個閉包呢

那實際上閉包的怎麼運作的呢?

有了前面這些基礎知識之後,我們再實際跑一次以下程式碼:

var v1 = 10
function test() {
  var vTest = 20
  function inner() {
    console.log(v1, vTest)
  }
  return inner
}
var inner = test()
inner()

-
首先一樣初始化 global EC

globalEC: {
  VO: {
    v1: undefined,
    test: func,
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
執行程式碼,並且在執行到 var inner = test() 時,進入 test EC

globalEC: {
  VO: {
    v1: 10,
    test: func,
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
進入 test EC

testEC = {
  AO: {
    vTest: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain

globalEC = {
  VO: {
   v1: 10,
   inner: undefined,
   test: func 
  },
  scopeChain: globalEC.VO
}

test.[[Scope]] = globalEC.scopeChain

-
執行程式碼,並且把 inner 回傳回去

testEC = {
  AO: {
    vTest: 20,
    inner: func
  },
  scopeChain: [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = [testEC.AO, globalEC.VO]

globalEC = {
  VO: {
   v1: 10,
   inner: undefined,
   test: func 
  },
  scopeChain: globalEC.VO
}

test.[[Scope]] = globalEC.scopeChain

執行完後,照理說 test 這個 function 結束了,所以 testEC 應該就要釋放掉。

BUT! 因為我們把 inner 這個 function 回傳了,而 inner.[[Scope]] 裡面還記著 testEC.AO,所以導致 testEC.AO 還存在記憶體裡無法釋放。

testEC 執行完了仍困在記憶體裡,這也是為什麼我們可以把閉包裡的值記住,因為他並沒有被釋放。

接下來只剩下執行 inner(),就不再贅述。


其實你用過閉包了

在我們學到 closure 之前,相信很多人其實已經跟他打過照面了

我們用一個常見的例子來說明

var arr = []

for(var i=0; i<5; i++) {
  arr[i] = function() {
    console.log(i)
  }
}

arr[0]()

這邊我們用迴圈,依序在 arr 裡面放入可以 log 出位置 0~4 的 function

所以我們執行 arr[0]() 時,預期會印出 0

但最後結果竟然印出 5

-
這是因為當我們宣告 var i=0 時,其實是將 i 宣告在 global

而我們跑迴圈時

第一圈 i=0

arr[0] = function(){
  console.log(i)
}

第二圈 i=1

arr[1] = function(){
  console.log(i)
}

...後面以此類推

i=5 時,因為 i 必須 < 5,所以跳出迴圈

所以當後面執行 arr[0]() 時,就會 call

arr[0] = function(){
  console.log(i)
}

此時的 i 就是 5,所以才會印出 5

那我們要怎麼改寫成我們想要的呢?

(1) 用一個新的 function 代替

var arr = []

for(var i=0; i<5; i++){
  arr[i] = logN(i)
}

function logN(n){
  return function(){
    console.log(n)
  }
}

arr[0]()

i=0 時,會執行 logN 並帶入 i

這時候會產生一個作用域並且回傳 i

由於用到閉包的緣故,這些值會被保留

因此之後 call 數字時,就會印出相同的數字

-

(2) 立即呼叫函式(IIFE, Immediately Invoked Function Expression)

其實跟上面一樣只是用 IIFE 的方式讓程式碼較簡潔,但可讀性會變差

IIFE 的形式其實就是 (func)(參數)

所以我們要把上面那個 logN 改成立即呼叫函式,只需要幾個步驟

arr[i] = logN(i)

第一,將 logN 換成原本另外寫的 function,並且前後加上()

arr[i] = (function logN(n){
  return function(){
    console.log(n)
  }
})(i)

第二,將原本的 function 名刪掉

arr[i] = (function (n){
  return function(){
    console.log(n)
  }
})(i)

就完成了!

所以就是以下的程式碼:

var arr = []

for(var i=0; i<5; i++){
  arr[i] = (function(n){
    return function(){
      console.log(n)
    }
  })(i)
}

arr[0]()

-
(3) 把 var 換成 let

因為 let 的 scope 是大括號內,因此迴圈跑 5 圈就會有 5 個 block

{
  let i = 0
  arr[0] = function(){
    console.log(i)
  }
}

{
  let i = 1
  arr[1] = function(){
    console.log(i)
  }
}

...以此類推,所以就可以印出想要的數字


所以閉包可以用在哪?

通常你會用 closure 是當你想隱藏東西的時候

什麼意思呢?我們用一個簡單的加減錢的算式來看

var money = 99

function add(num){
  money += num
}

function deduct(num){
  if(num >= 10){
    money -= 10
  }else{
    money -= num
  }
}

add(1)
deduct(100)
console.log(money)

答案便會是 90

但這樣做其實會有風險,如果我在 log 前面再加一行 money = -1,答案就會變了

要怎麼避免這件事呢,利用 closure

function createWallet(initMoney) {
  var money = initMoney
  return {
    add: function(num) {
      money += num
    },
    deduct: function(num) {
      if(num >= 10) {
        money -= 10
      }else{
        money -= num
      }
    },
    getMoney() {
      return money
    }
  }
}

var myWallet = createWallet(99)
myWallet.add(1)
myWallet.deduct(100)
console.log(myWallet.getMoney())

這樣的寫法會讓我們無法從外部直接改寫 money 的值

只能用這個函式提供的 add & deduct

自由度降低,但相對較安全

以上就是 closure 的簡單介紹,可以參考這篇:所有的函式都是閉包:談 JS 中的作用域與 Closure,會有更詳細的說明!


#closure #javascript







Related Posts

Colab 中開啟 Tensorboard

Colab 中開啟 Tensorboard

LeetCode JS Easy 2704. To Be Or Not To Be

LeetCode JS Easy 2704. To Be Or Not To Be

APIFlask 初始化專案 - Part2

APIFlask 初始化專案 - Part2


Comments